Skip to content
Clone
4c83001
547 lines 17.7 KB Rust
use std::collections::HashMap;
use models::space::space_group_permissions;
use models::space::space_groups;
use models::space::space_member_groups;
use models::space::space_members;
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter,
QueryOrder, QuerySelect, Set, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::errors::SpaceError;
use crate::services::service::SpaceService;
use crate::services::spaces::{PageQuery, UserBriefDto};
use crate::share::authz::{effective_permissions, require_perm, MANAGE_ROLES};
use crate::share::lookup::lookup_space_by_name;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct GroupDto {
pub id: Uuid,
pub space_id: Uuid,
pub name: String,
pub position: i64,
pub color: String,
pub unicode_emoji: Option<String>,
pub icon_hash: Option<String>,
pub version: i32,
pub created_at: OffsetDateTime,
pub updated_at: Option<OffsetDateTime>,
pub created_by: Option<UserBriefDto>,
pub permissions: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateGroupCmd {
pub name: String,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub unicode_emoji: Option<String>,
#[serde(default)]
pub icon_hash: Option<String>,
#[serde(default)]
pub position: Option<i64>,
#[serde(default)]
pub permissions: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpdateGroupCmd {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub unicode_emoji: Option<Option<String>>,
#[serde(default)]
pub icon_hash: Option<Option<String>>,
#[serde(default)]
pub position: Option<i64>,
#[serde(default)]
pub permissions: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct AssignGroupCmd {
pub group_id: Uuid,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct GroupPage {
pub items: Vec<GroupDto>,
pub total: i64,
}
async fn replace_permissions(
db: &impl ConnectionTrait,
group_id: Uuid,
permissions: &[String],
) -> Result<(), SpaceError> {
space_group_permissions::Entity::delete_many()
.filter(space_group_permissions::Column::GroupId.eq(group_id))
.exec(db)
.await?;
for perm in permissions {
let row = space_group_permissions::ActiveModel {
group_id: Set(group_id),
permission: Set(perm.clone()),
};
row.insert(db).await?;
}
Ok(())
}
impl SpaceService {
pub async fn create_group(
&self,
caller: Uuid,
space_name: &str,
cmd: CreateGroupCmd,
) -> Result<GroupDto, SpaceError> {
let space = lookup_space_by_name(self, space_name).await?;
if space.owner_id != caller {
return Err(SpaceError::Forbidden);
}
let name = cmd.name.trim();
if name.is_empty() || name.chars().count() > self.config().space.max_group_name_len {
return Err(SpaceError::BadRequest("group name is empty or too long".to_string()));
}
let existing = space_groups::Entity::find()
.filter(space_groups::Column::SpaceId.eq(space.id))
.filter(space_groups::Column::Name.eq(name))
.one(self.db().read())
.await?;
if existing.is_some() {
return Err(SpaceError::Conflict(format!(
"group name {name} already exists in this space"
)));
}
let now = OffsetDateTime::now_utc();
let group_id = Uuid::now_v7();
let group_row = space_groups::ActiveModel {
id: Set(group_id),
space_id: Set(space.id),
name: Set(name.to_string()),
position: Set(cmd.position.unwrap_or(0)),
color: Set(cmd.color.unwrap_or_default()),
unicode_emoji: Set(cmd.unicode_emoji),
icon_hash: Set(cmd.icon_hash),
version: Set(1),
created_at: Set(now),
updated_at: Set(None),
created_by: Set(caller),
};
let permissions = cmd.permissions;
let perms_clone = permissions.clone();
let inserted = self
.db()
.primary()
.transaction::<_, space_groups::Model, SpaceError>(|txn| {
Box::pin(async move {
let g = group_row.insert(txn).await?;
replace_permissions(txn, group_id, &perms_clone).await?;
Ok(g)
})
})
.await?;
let user_map = self.load_user_briefs(&[caller]).await?;
let creator_brief = user_map.get(&caller).cloned();
Ok(GroupDto {
id: inserted.id,
space_id: inserted.space_id,
name: inserted.name,
position: inserted.position,
color: inserted.color,
unicode_emoji: inserted.unicode_emoji,
icon_hash: inserted.icon_hash,
version: inserted.version,
created_at: inserted.created_at,
updated_at: inserted.updated_at,
created_by: creator_brief,
permissions,
})
}
pub async fn get_group(
&self,
caller: Option<Uuid>,
space_name: &str,
group_id: Uuid,
) -> Result<GroupDto, SpaceError> {
let space = lookup_space_by_name(self, space_name).await?;
if !space.is_active {
return Err(SpaceError::NotFound);
}
let group = space_groups::Entity::find_by_id(group_id)
.filter(space_groups::Column::SpaceId.eq(space.id))
.one(self.db().read())
.await?
.ok_or(SpaceError::NotFound)?;
if space.is_private {
let Some(uid) = caller else {
return Err(SpaceError::NotFound);
};
if require_perm(self, &space, uid, MANAGE_ROLES).await.is_err() {
return Err(SpaceError::NotFound);
}
}
let perms = space_group_permissions::Entity::find()
.filter(space_group_permissions::Column::GroupId.eq(group.id))
.all(self.db().read())
.await?;
let permissions = perms.into_iter().map(|p| p.permission).collect();
let user_map = self.load_user_briefs(&[group.created_by]).await?;
let creator_brief = user_map.get(&group.created_by).cloned();
Ok(GroupDto {
id: group.id,
space_id: group.space_id,
name: group.name,
position: group.position,
color: group.color,
unicode_emoji: group.unicode_emoji,
icon_hash: group.icon_hash,
version: group.version,
created_at: group.created_at,
updated_at: group.updated_at,
created_by: creator_brief,
permissions,
})
}
pub async fn update_group(
&self,
caller: Uuid,
space_name: &str,
group_id: Uuid,
cmd: UpdateGroupCmd,
) -> Result<GroupDto, SpaceError> {
let space = lookup_space_by_name(self, space_name).await?;
if space.owner_id != caller {
return Err(SpaceError::Forbidden);
}
let group = space_groups::Entity::find_by_id(group_id)
.filter(space_groups::Column::SpaceId.eq(space.id))
.one(self.db().read())
.await?
.ok_or(SpaceError::NotFound)?;
let mut am: space_groups::ActiveModel = group.into();
if let Some(new_name) = cmd.name {
let name = new_name.trim();
if name.is_empty() || name.chars().count() > self.config().space.max_group_name_len {
return Err(SpaceError::BadRequest("group name is empty or too long".to_string()));
}
let existing = space_groups::Entity::find()
.filter(space_groups::Column::SpaceId.eq(space.id))
.filter(space_groups::Column::Name.eq(name))
.filter(space_groups::Column::Id.ne(group_id))
.one(self.db().read())
.await?;
if existing.is_some() {
return Err(SpaceError::Conflict(format!(
"group name {name} already exists in this space"
)));
}
am.name = Set(name.to_string());
}
if let Some(color) = cmd.color {
am.color = Set(color);
}
if let Some(unicode_emoji) = cmd.unicode_emoji {
am.unicode_emoji = Set(unicode_emoji);
}
if let Some(icon_hash) = cmd.icon_hash {
am.icon_hash = Set(icon_hash);
}
if let Some(position) = cmd.position {
am.position = Set(position);
}
let curr_ver = am.version.clone().unwrap();
am.version = Set(curr_ver + 1);
let now = OffsetDateTime::now_utc();
am.updated_at = Set(Some(now));
let perms_to_update = cmd.permissions.clone();
let updated = self
.db()
.primary()
.transaction::<_, space_groups::Model, SpaceError>(|txn| {
Box::pin(async move {
let g = am.update(txn).await?;
if let Some(ref perms) = perms_to_update {
replace_permissions(txn, group_id, perms).await?;
}
Ok(g)
})
})
.await?;
let perms = space_group_permissions::Entity::find()
.filter(space_group_permissions::Column::GroupId.eq(updated.id))
.all(self.db().read())
.await?;
let permissions = perms.into_iter().map(|p| p.permission).collect();
let user_map = self.load_user_briefs(&[updated.created_by]).await?;
let creator_brief = user_map.get(&updated.created_by).cloned();
Ok(GroupDto {
id: updated.id,
space_id: updated.space_id,
name: updated.name,
position: updated.position,
color: updated.color,
unicode_emoji: updated.unicode_emoji,
icon_hash: updated.icon_hash,
version: updated.version,
created_at: updated.created_at,
updated_at: updated.updated_at,
created_by: creator_brief,
permissions,
})
}
pub async fn delete_group(
&self,
caller: Uuid,
space_name: &str,
group_id: Uuid,
) -> Result<(), SpaceError> {
let space = lookup_space_by_name(self, space_name).await?;
if space.owner_id != caller {
return Err(SpaceError::Forbidden);
}
let group = space_groups::Entity::find_by_id(group_id)
.filter(space_groups::Column::SpaceId.eq(space.id))
.one(self.db().read())
.await?
.ok_or(SpaceError::NotFound)?;
self.db()
.primary()
.transaction::<_, (), SpaceError>(|txn| {
Box::pin(async move {
space_member_groups::Entity::delete_many()
.filter(space_member_groups::Column::GroupId.eq(group.id))
.exec(txn)
.await?;
space_group_permissions::Entity::delete_many()
.filter(space_group_permissions::Column::GroupId.eq(group.id))
.exec(txn)
.await?;
space_groups::Entity::delete_by_id(group.id)
.exec(txn)
.await?;
Ok(())
})
})
.await?;
Ok(())
}
pub async fn list_groups(
&self,
caller: Uuid,
space_name: &str,
q: PageQuery,
) -> Result<GroupPage, SpaceError> {
let space = lookup_space_by_name(self, space_name).await?;
require_perm(self, &space, caller, MANAGE_ROLES).await?;
let max_pagination = self.config().space.max_pagination;
let default_pagination = self.config().space.default_pagination;
let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
let offset = q.offset.unwrap_or(0);
let select = space_groups::Entity::find()
.filter(space_groups::Column::SpaceId.eq(space.id))
.order_by_asc(space_groups::Column::Position);
let total = select.clone().count(self.db().read()).await? as i64;
let rows = select
.limit(limit)
.offset(offset)
.all(self.db().read())
.await?;
let group_ids: Vec<Uuid> = rows.iter().map(|g| g.id).collect();
let creator_uids: Vec<Uuid> = rows.iter().map(|g| g.created_by).collect();
let all_perms = space_group_permissions::Entity::find()
.filter(space_group_permissions::Column::GroupId.is_in(group_ids))
.all(self.db().read())
.await?;
let mut perms_map: HashMap<Uuid, Vec<String>> = HashMap::new();
for p in all_perms {
perms_map.entry(p.group_id).or_default().push(p.permission);
}
let user_map = self.load_user_briefs(&creator_uids).await?;
let items = rows
.into_iter()
.map(|g| {
let creator_brief = user_map.get(&g.created_by).cloned();
let permissions = perms_map.remove(&g.id).unwrap_or_default();
GroupDto {
id: g.id,
space_id: g.space_id,
name: g.name,
position: g.position,
color: g.color,
unicode_emoji: g.unicode_emoji,
icon_hash: g.icon_hash,
version: g.version,
created_at: g.created_at,
updated_at: g.updated_at,
created_by: creator_brief,
permissions,
}
})
.collect();
Ok(GroupPage { items, total })
}
pub async fn assign_group(
&self,
caller: Uuid,
space_name: &str,
uid: Uuid,
cmd: AssignGroupCmd,
) -> Result<(), SpaceError> {
let space = lookup_space_by_name(self, space_name).await?;
if space.owner_id != caller {
return Err(SpaceError::Forbidden);
}
space_groups::Entity::find_by_id(cmd.group_id)
.filter(space_groups::Column::SpaceId.eq(space.id))
.one(self.db().read())
.await?
.ok_or(SpaceError::NotFound)?;
space_members::Entity::find_by_id((space.id, uid))
.one(self.db().read())
.await?
.ok_or(SpaceError::NotFound)?;
let now = OffsetDateTime::now_utc();
let row = space_member_groups::ActiveModel {
space_id: Set(space.id),
user_id: Set(uid),
group_id: Set(cmd.group_id),
created_at: Set(now),
};
row.insert(self.db().write()).await?;
Ok(())
}
pub async fn unassign_group(
&self,
caller: Uuid,
space_name: &str,
uid: Uuid,
group_id: Uuid,
) -> Result<(), SpaceError> {
let space = lookup_space_by_name(self, space_name).await?;
if space.owner_id != caller {
return Err(SpaceError::Forbidden);
}
space_member_groups::Entity::delete_many()
.filter(space_member_groups::Column::SpaceId.eq(space.id))
.filter(space_member_groups::Column::UserId.eq(uid))
.filter(space_member_groups::Column::GroupId.eq(group_id))
.exec(self.db().write())
.await?;
Ok(())
}
pub async fn join_group_self(
&self,
caller: Uuid,
space_name: &str,
group_id: Uuid,
) -> Result<(), SpaceError> {
let space = lookup_space_by_name(self, space_name).await?;
effective_permissions(self.db().read(), &space, caller).await?;
if !self.config().space.allow_self_join_groups {
return Err(SpaceError::Forbidden);
}
space_groups::Entity::find_by_id(group_id)
.filter(space_groups::Column::SpaceId.eq(space.id))
.one(self.db().read())
.await?
.ok_or(SpaceError::NotFound)?;
let existing = space_member_groups::Entity::find_by_id((space.id, caller, group_id))
.one(self.db().read())
.await?;
if existing.is_some() {
return Err(SpaceError::Conflict("already in group".to_string()));
}
let now = OffsetDateTime::now_utc();
let row = space_member_groups::ActiveModel {
space_id: Set(space.id),
user_id: Set(caller),
group_id: Set(group_id),
created_at: Set(now),
};
row.insert(self.db().write()).await?;
Ok(())
}
pub async fn leave_group_self(
&self,
caller: Uuid,
space_name: &str,
group_id: Uuid,
) -> Result<(), SpaceError> {
let space = lookup_space_by_name(self, space_name).await?;
effective_permissions(self.db().read(), &space, caller).await?;
let res = space_member_groups::Entity::delete_many()
.filter(space_member_groups::Column::SpaceId.eq(space.id))
.filter(space_member_groups::Column::UserId.eq(caller))
.filter(space_member_groups::Column::GroupId.eq(group_id))
.exec(self.db().write())
.await?;
if res.rows_affected == 0 {
return Err(SpaceError::NotFound);
}
Ok(())
}
}